Map generation improvements: modular framework and twelve new generators - #240
Conversation
9e9f0f1 to
b74f6df
Compare
#238 (already merged) added CustomGamePreferences.h, which serializes CustomGameSetup::generator by taking Sint32 MapGenerationDescriptor::* member pointers directly. #240 replaced generator's type with GenerationRequest, whose method-specific options live in a generic std::map<std::string,int> instead of fixed struct fields — an architecture change, not a rename — so the file no longer compiled. - CustomGamePreferences::encode()/decode() now convert through toLegacyDescriptor()/fromLegacyDescriptor() (the compatibility adapter #240 already built for exactly this kind of interop), so the on-disk wire format and its corruption-recovery bounds are unchanged. decode()'s method-validity check now asks the live GeneratorRegistry instead of a hardcoded 1-8 range, so it stays correct as generators are added or retired. - Widened several fields() bounds (terrain weights 0-64 -> 0-100, riverDiameter's max 64 -> 65, oldIslandSize 1-64 -> 1-70) to match the modular registry's current ranges. These are approximate, same as before: the reused legacy fields (e.g. riverDiameter also stands in for lake size/channel width/bridge width) don't have one true bound, so this is a safe envelope, not a tight per-method one. Without this, decode() could reject a preferences file that a normal save legitimately produced (oldIslandSize's own default already exceeded the old 1-64 bound for any method other than Isles/Old Islands). - Found and fixed a related crash bug in the compatibility adapter itself while tracing this: Lattice and Maze register wheat/wood/ stone/algae controls with no entry in legacyField()'s mapping table, so converting either method through toLegacyDescriptor/ fromLegacyDescriptor threw an uncaught std::invalid_argument. Added the four missing mappings (they match pre-existing legacy struct fields exactly) and changed every other option with no legacy slot (loopiness, home-radius, cell-size, ...) from throwing to falling back to its control's default value, so a newer generator's full option set can never crash this adapter again. - test/CustomGameSetupHarness.cpp: preferencesModel()/preferencesScreen() built a GenerationRequest via the same member-pointer approach; updated both to build a temporary MapGenerationDescriptor and convert. Switched preferencesModel()'s method from Old Islands to Crater Lakes (one of the four modern height-map generators that still exposes a repeat-landscape control; Old Islands never did in the new registry, so it always round-tripped back to 0). The per-field assertions in preferencesScreen() now compare against the same achievable conversion rather than raw field maximums, since only the options a method actually registers survive a GenerationRequest round trip. Verified: full scons -j8 release=1 server=0 client build is clean. CustomGameSetupHarness passes in default, preferences-write and preferences-read modes. MapGeneratorDefaultsTest and MapGeneratorStudy --catalog also pass, unaffected by the adapter fix. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KFxsZmLM4qsovemHqDrHGP
e0508e0 to
2a0895c
Compare
|
Rebased onto the (also just-rebased) #238 branch — it forked from the same Most conflicts were pure append/append (translation files) or a stale-vs-final doc mismatch, resolved by taking this branch's own final version of The real find, after conflicts resolved: the branch didn't compile. While tracing that, I also found and fixed an unrelated crash in the compatibility adapter itself: Lattice and Maze register Verification on this head:
Requesting review from @Giszmo and @kylelutze (same as #238, since this stacks directly on it). No merge performed. |
Merging directly. CI green on all three platforms, no merge conflicts, no reviewer objections. Stacked map-generator work (#240) follows.
#238 (already merged) added CustomGamePreferences.h, which serializes CustomGameSetup::generator by taking Sint32 MapGenerationDescriptor::* member pointers directly. #240 replaced generator's type with GenerationRequest, whose method-specific options live in a generic std::map<std::string,int> instead of fixed struct fields — an architecture change, not a rename — so the file no longer compiled. - CustomGamePreferences::encode()/decode() now convert through toLegacyDescriptor()/fromLegacyDescriptor() (the compatibility adapter #240 already built for exactly this kind of interop), so the on-disk wire format and its corruption-recovery bounds are unchanged. decode()'s method-validity check now asks the live GeneratorRegistry instead of a hardcoded 1-8 range, so it stays correct as generators are added or retired. - Widened several fields() bounds (terrain weights 0-64 -> 0-100, riverDiameter's max 64 -> 65, oldIslandSize 1-64 -> 1-70) to match the modular registry's current ranges. These are approximate, same as before: the reused legacy fields (e.g. riverDiameter also stands in for lake size/channel width/bridge width) don't have one true bound, so this is a safe envelope, not a tight per-method one. Without this, decode() could reject a preferences file that a normal save legitimately produced (oldIslandSize's own default already exceeded the old 1-64 bound for any method other than Isles/Old Islands). - Found and fixed a related crash bug in the compatibility adapter itself while tracing this: Lattice and Maze register wheat/wood/ stone/algae controls with no entry in legacyField()'s mapping table, so converting either method through toLegacyDescriptor/ fromLegacyDescriptor threw an uncaught std::invalid_argument. Added the four missing mappings (they match pre-existing legacy struct fields exactly) and changed every other option with no legacy slot (loopiness, home-radius, cell-size, ...) from throwing to falling back to its control's default value, so a newer generator's full option set can never crash this adapter again. - test/CustomGameSetupHarness.cpp: preferencesModel()/preferencesScreen() built a GenerationRequest via the same member-pointer approach; updated both to build a temporary MapGenerationDescriptor and convert. Switched preferencesModel()'s method from Old Islands to Crater Lakes (one of the four modern height-map generators that still exposes a repeat-landscape control; Old Islands never did in the new registry, so it always round-tripped back to 0). The per-field assertions in preferencesScreen() now compare against the same achievable conversion rather than raw field maximums, since only the options a method actually registers survive a GenerationRequest round trip. Verified: full scons -j8 release=1 server=0 client build is clean. CustomGameSetupHarness passes in default, preferences-write and preferences-read modes. MapGeneratorDefaultsTest and MapGeneratorStudy --catalog also pass, unaffected by the adapter fix. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KFxsZmLM4qsovemHqDrHGP
Rebased onto master now that #238 has merged. That surfaced a real compile break: the harness still called Map::oldMakeIslandsMap/Game::oldMakeIslandsMap, which the registry-driven generator rewrite removed. Route it through MapGenerator::generateMap(Game&, const MapGenerationDescriptor&, seed) instead, which owns map sizing and the game association internally. Seed explicitly since generation determinism no longer follows the global sync-rand state. Also start from setMethodDefaults() rather than hand-picked constants: #238's defaults tuning tightened several control ranges (island-size moved to 50-70, the shared "workers" control caps at 8), so the harness's old literals (oldIslandSize=35, nbWorkers=48) now fail request validation. Defaulting first and overriding only what this decorative colony actually needs to differ keeps it from rotting the same way again as ranges keep moving. Verified: full client and menu-colony-harness build clean; `check`, `navigation`, and `generate` subcommands all pass (generate grows the colony from 8 to 56 units over the same 12,000-tick warmup, confirming the reduced starting worker count doesn't defeat the decorative intent). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Le23P4h9QuDjwExuK6HU96
2a0895c to
db88fb0
Compare
|
Retargeted onto master and rebased now that #238 has merged. Master's tree was byte-identical to #238's old branch tip (clean squash merge), so That surfaced a real compile break in CI, though:
Verified on this head (
CI is running now. Requesting review from @Giszmo and @kylelutze (unchanged from the original description). No merge performed. |
…or bias Building on #240's modular generator rewrite: swamp, river, islands, crater lakes, old-random and old-islands all pick each team's resources through a step that has no idea where any *other* team ended up, or in old-random/ old-islands' case scores compass directions independently per team without comparing outcomes across teams. A colony's proximity to wheat and wood was left almost entirely to how the (still resource-blind) starting-position search happened to place it, so one team could land next to both while another got only one, or occasionally neither within playable range. Add shared/Resources::guaranteeStartingResources, which floods outward from each team's boot tile through the same walkable-space search used to judge distance (isHardSpaceForGroundUnit), so anything it places is reachable by construction rather than merely straight-line close. It tops up only teams that are missing wheat or wood within the range map_generator_study.py already scores as viable; already-served teams are untouched. Wired into the four generators that share Terrain::generateHeightField, and into old-random/old-islands after their own start placement. Also fix a starvation bug in old-random's resource search: its 8-direction scan hardcoded a 4th slot to CORN, so every colony got a guaranteed second wheat deposit while wood only ever got one. The 4th slot is now awarded to whichever of the two came out narrower for that colony. Extend test/MapGeneratorStudy.cpp's tuning output with best_wheat_distance/ best_wood_distance (previously only the worst-served team's distance was tracked) so tools/map_generator_study.py can size the gap between a map's best- and worst-served colony, not just its worst case in isolation. Validated with 200 fixed seeds per generator via map_generator_study.py (artifacts not committed, matching this directory's own convention): generation success rate is unchanged for every generator (including old-random and old-islands' existing ~4-5% baseline failure rates); the wheat-access gap between a map's best- and worst-served colony drops for swamp, river, islands and crater lakes, and old-random's wood gap and worst case both drop by roughly two-fifths. Old-random's wheat gap grew in the same run and a handful of colonies on constrained terrain (river was the clearest case) can still end up with no reachable wheat or wood at all; both are documented as open follow-ups in docs/map-generators/FRAMEWORK_UPGRADES.md; a farthest-point starting- position search was tried against the latter and measured worse on river and islands, so it was not kept. This is a balance-affecting change to established generators and needs a maintainer's sign-off per this repo's review rules, not just this PR's own validation numbers. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Rc6FTwBEB6LKNUmamX9wWW
Follow-up to 777b748. That commit's guaranteeStartingResources fixed most of the fairness gap but left river with a residual ~5% of colonies reaching no wheat or wood at all, and left old-random's imbalance direction flipped rather than closed. Investigated the river failures directly (map dumps + reachability analysis, not just aggregate metrics this time): a colony's boot tile was landing on a stretch of perfectly good, well connected land — confirmed by re-flooding the same tile through terrain alone, ignoring resources, and finding a landmass orders of magnitude larger than what the resource-respecting flood could reach. A ground unit can't walk onto a tile carrying a resource (Map::isHardSpaceForGroundUnit excludes them), and the noise-band resource painting has no idea what it might wall in, so it was sealing colonies into small pockets on their own otherwise-fine landmass. guaranteeStartingResources now detects this directly: when a team's resource-respecting reachable area is suspiciously small, it re-floods the same tile blocked only by water (ignoring resources) and diffs the two results to find exactly the resource tiles forming the wall's face — clearing only those, not a surrounding neighborhood, and iterating a bounded number of times in case a wall is thicker than one tile. A colony on a genuinely small spot (a real islet on a water-heavy map) is left alone: there's no larger area on the other side of nothing, so the terrain-only flood finds nothing bigger and the check is a no-op by construction. First attempt at this used a blunt fixed-radius clear around the boot tile whenever the pocket looked too small, gated only on the team actually being under-served. It worked for river but silently regressed swamp (whose terrain legitimately includes small real islands): demolishing a 25x25 tile neighborhood around a "small but actually fine" pocket destroys perfectly good nearby deposits for no gain when there is no larger landmass to reach. The precise wall-tracing approach here has no such failure mode, validated below. Reconfirmed with the same 200-fixed-seed methodology as 777b748: river's rate of a colony reaching no wheat or wood at all drops from ~5% to ~3% (the earlier blunt version reached 0% on this seed range but at swamp's expense — see above), with the remaining cases past this guarantee's own search radius. Swamp's numbers now improve slightly rather than regressing (the false-blame that motivated the blunt version's guard is gone: the gate is precise instead of merely permission-checked). Crater lakes, islands, old-random and old-islands are unaffected (the mechanism only fires where floodReach and a terrain-only flood actually disagree). Generation success rate is unchanged everywhere. Confirmed the existing map-generator-defaults-test and a full non-server engine build still pass. Old-random's wheat/wood imbalance (flipped, not fixed, by 777b748) and the remaining few percent of unreachable river colonies stay open follow-ups, called out in docs/map-generators/FRAMEWORK_UPGRADES.md along with why a join symmetric compass-slot assignment was tried and not kept for the former. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Rc6FTwBEB6LKNUmamX9wWW
The four height-field generators picked colony sites before any resource was on the map, so a site could only be judged by the shape of the grass under it: largest contiguous grass rectangle to the first colony, and everyone after it taking what was left. Every fairness fix so far has been downstream repair of that decision. This changes the decision. The ordering turned out not to be a constraint at all. Nothing between the placement pass and the resource pass reads a boot position or draws from a random stream, so the two simply swap, and placement can then see what it is actually choosing between. Measured the headroom before building anything, via a new `headroom` mode in the study tool: score every legal site against a single multi-source flood per resource (one flood answers the question for the whole map, so scoring a site is a lookup rather than its own search), then find the narrowest score window still holding enough mutually distant sites. Result: every map sampled, on every generator, already contained a placement where all colonies were equally served. The maps were never unfair. The placement was throwing away fairness the map already had. shared/StartingPositions::chooseBalancedStarts now picks sites after the resource pass. A site scores as its *worse* primary resource, since a colony beside wood but a long walk from wheat is not a good start; sites are sorted and the narrowest qualifying window wins, scanned from the low-score end so ties settle in favour of a set that is not merely equal but good. The legacy search remains as a fallback for maps where no set of sites can reach both resources. Across 200 fixed seeds per generator, versus the pre-existing behaviour: gap between best- and worst-served colony 9.5-19.0 -> 3.5-4.0 tiles worst-served colony's walk to a resource 12.0-18.5 -> 3.5-4.2 tiles colonies reaching no wood or wheat at all up to 5.6% -> 0% colonies clearing the study tool's viability bar all of them The worst-served colony is now better off than the *luckiest* colony was before. Verified at 2, 6, 8 and 12 colonies as well (gap 2.4-7.4, widening with colony count as expected, no generation failures introduced at any count). Success rates, resource totals and colony separation (30-72 tiles apart on a 128x128 map) are unchanged. map-generator-defaults-test and a full non-server engine build pass. Old-random and old-islands deliberately keep their own placement: they put resources relative to each boot tile, so moving a colony does not bring its resources along. The same headroom measurement puts their achievable gap at 8.0 and 0.9 tiles, and old-islands already sits at 0.9. This changes how these four generators play, and that part is a maintainer's call rather than a metric's: colonies now start snug against their wood and wheat instead of in open ground, and buildable room within reach of the worst-served colony drops 14-28% (still 405-642 free building sites against a viability bar of 16). Generator revisions are bumped, so a given seed no longer produces the map it did before. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Rc6FTwBEB6LKNUmamX9wWW
Placement picks a site for its walk to wood and wheat, but a colony rewrites its own surroundings the instant it is built: placeStarts() clears a five by seven box of resources to make room, the swarm itself becomes four by four tiles of obstacle, and the workers spawn on the row above the boot tile rather than on it. Scoring against the bare map therefore credited a site with deposits it was about to destroy and paths it was about to block. That was measurable, not theoretical. Instrumenting the search on one seed showed it choosing four sites scored wood 0, wheat 0 apiece -- exactly equal -- which the finished map then spread across three tiles, because how much each site loses to its own construction depends on the shape of the resources around it. chooseBalancedStarts now shortlists on the cheap bare-map distance, which can only understate the built cost and so is a sound filter, then re-scores the shortlist by simulating the finished colony: clearing box excluded, swarm footprint impassable, flood starting from the worker row. The offsets mirror placeStarts() rather than approximating it. Against the previous commit, over 200 fixed seeds per generator: gap between best- and worst-served colony 3.5-4.0 -> 0.92-1.02 tiles worst-served colony's walk 3.5-4.2 -> 2.0-2.3 tiles Better on both counts at once, and level with old-islands, the fair-by-construction benchmark. Holds at 2, 8 and 12 colonies (gap 0.35-4.70, widening with count as expected, no failures at any count). Buildable room, resource totals and colony separation are unchanged. map-generator-defaults-test and a full non-server engine build pass. A first attempt instead excluded every site whose deposits sat inside the clearing box. It tightened the gap to 2.8-3.1 but pushed every colony roughly twice as far from its resources (worst walk 3.5 -> 6.6 tiles), trading what matters for what was being measured. Not kept. Generation costs 63-110ms against 11-14ms, nearly all of it the shortlist re-scoring, and the first working version cost 1400ms until the visited set stopped being a linear scan. Imperceptible for a lobby generating one map. Halving the shortlist halves the cost but loses a third of the gain on river, so the shortlist stays at 900. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Rc6FTwBEB6LKNUmamX9wWW
Map::growResources decides whether a wheat or wood tile expands with
dwax = (syncRand()&0xF) - (syncRand()&0xF); // likewise dway
expand = isWater(x+dwax, y+dway) && !isSand(x-dwax, y-dway);
The difference of two uniform draws from {0..15} has PMF (16-|d|)/256, so
the chance a tile expands is a triangular kernel summed over every water
tile whose mirror through the tile is not sand. FertilityCalculator fit a
curve to the first half of that and dropped the second: its weights were
int(4.2*sqrt((15-|dx|)*(15-|dy|))) and it never looked at sand at all,
overstating exactly the tiles where wheat will not in fact come back.
Fertility::Field evaluates the real thing in closed form. A length-16
forward box followed by a length-16 backward box is exactly the triangular
kernel, so the water term is four linear passes rather than 961 taps per
grass tile, and the sand term is one stamp per sand tile. Measured over 20
seeds on each of eight generators it runs about ten times faster than the
kernel it replaces (the sand correction, not the convolution, is what it
spends its time on) and correlates 0.89-0.997 with it; shattered-coast,
the sandiest of them, is both the least correlated and the slowest, which
is the difference being paid for.
The field takes plain masks and no Map, so map generation can score a
candidate's growth potential without a live game. FertilityCalculator
keeps its API, its deposit-reachability gate and fertilityMaximum, and
clamps to the Uint16 that Tile::fertility holds; the scale is now 65536,
which the overlay normalises away and MapIO stores per tile as before.
The implementation comes from ExactFertilityCache in AIMaximaFarming on
the Maxima branch, extracted here so both callers share one field.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Rc6FTwBEB6LKNUmamX9wWW
Placement equalises walking distance to wood and wheat, and on four of these generators that is now down to two tiles for the worst-served colony. It is also nearly all the metric has left to give: sampling twenty maps and keeping the fairest shaves two tenths of a tile off a gap of one. Distance was always a thin account of a starting position. A deposit at the door that runs dry, a colony with no room to build, one boxed between two rivals, and one on ground where wheat never grows back all look alike to it. StartQuality measures six things per colony on the finished map -- where the swarm is built, its clearing cleared and the workers standing where they will actually start walking -- and folds them into one number per map: the weakest colony's quality, gated by how evenly the map shared quality out, worst * (worst/best)^k. Fertility carries the most weight of the six because it is the one that decides whether a colony's wheat comes back at all; the others describe what it starts with, fertility describes what it keeps. Every factor is normalised against a fixed reference rather than the map's own best colony, because the point is to rank candidate maps against each other and a per-map normalisation scores every map alike. The two distances use the viability bars the study tool already scores against; the other four have no such bar, so they sit near the ninetieth percentile of what the twelve generators actually produce, measured over 2400 maps. Scoring costs 2.5-5.4ms against 11-61ms to generate, consumes no random stream, and never rejects a map -- it ranks. Map hashes are unchanged. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Rc6FTwBEB6LKNUmamX9wWW
The lobby already generated up to five maps and kept the first that succeeded, because some rolls cannot fit every starting colony. Among the rolls that do fit, some still hand one colony far better ground than another, and now that there is a score for that, the same loop can keep the best rather than the first. Five is a budget, not a knee. Keeping the best of K is the maximum of K draws from a distribution, so it grows like sqrt(2 ln K): it improves forever and flattens gradually, and bootstrapped over 200 scored maps per generator all twelve trace that same curve to within a few percent -- three candidates capture 44% of what fifty give, five 59%, eight 70%, twenty 87%. What picks the cutoff is that the lobby generates on the UI thread behind a 500ms debounce: at 61ms a candidate on crater-lakes, five rolls cost 306ms where eight would cost 489ms and spend the whole budget. The editor owns its Game and cannot hold a spare, so it takes the winning seed from GenerationService::bestSeed and regenerates it. That is sound because generation is deterministic and the score is a pure function of the finished map; map-generator-defaults-test asserts both, along with the chosen seed being a candidate no other candidate outscores. Optimising a composite could have pulled colonies away from their resources to chase fertility or elbow room. Measured, it does not: the worst-served colony's walk holds at about two tiles on the four height-field generators and improves by four on shattered-coast, while fairness rises from 0.81-0.94 to 0.92-0.97 everywhere. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Rc6FTwBEB6LKNUmamX9wWW
…d static Profiling map generation under load (users noticed the "keep the best of five rolls" work from the previous commit made map generation feel much slower) found chooseBalancedStarts itself, plus Map::isHardSpaceForGroundUnit called from inside it, accounting for 74-84% of total generation time on swamp, river, islands and crater lakes: two whole-map resource floods each call it once per tile, and then every one of up to 900 shortlisted candidate sites re-simulates its own local flood calling it again, up to ~4225 tile visits each in the worst case. That work was entirely redundant. Every call site in this file uses the same two constants (canSwim=false, team mask=0), which makes checkTile's forbidden- area test a no-op, and chooseBalancedStarts runs before placeStarts() ever places a building, so getBuilding() is NOGBID everywhere it looks. Under those invariants isHardSpaceForGroundUnit(x, y, false, 0) reduces to !isResource(x, y) && !isWater(x, y) — a pure function of terrain/resource state that cannot change across the whole search. buildHardSpaceGrid() computes that once into a flat std::vector<uint8_t> before the two distanceToResource() floods; all three flood loops (the two global ones and scoreAsBuilt's per-site one) read the cached byte instead of calling through checkTile's several field accessors. distanceToResource's distance vectors and the per-site visited/visitStamp array are narrowed from int to 16 bits alongside this — every distance this engine can produce fits comfortably, and the arrays these floods touch millions of times are half the size to move through cache. This changes nothing about which sites get chosen or how they get scored, only how the unchanged answer is computed: MapGeneratorStudy's per-tile terrain/resource hash is bit-identical before and after on matching seeds across every affected generator. Measured 28-31% faster end to end on swamp, river, islands and crater lakes (interleaved before/after binaries, 60 generations per generator, to separate the effect from this machine's own run-to-run noise) with zero measurable change on the nine generators that don't call chooseBalancedStarts, which is the expected result and also rules out the noise itself producing the improvement. Verified: full scons client build and scons -C test suite (198 cppunit cases) pass. MapGeneratorStudy succeeds across all 13 generators at 5 seeds each, both before and after. FertilityFieldTest and MapGeneratorDefaultsTest are unaffected (this file's callers are unrelated to either). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CkotX2daY8YTuFcRxFf5ba
Investigated as part of the same profiling pass as the previous commit: Fertility::Field::rebuild is the dominant cost on Lattice and Maze (57% and 39% of total generation time), but unlike chooseBalancedStarts this is not redundant recomputation — Fertility::forMap has exactly one caller (scoreStarts) and runs once per roll, confirmed via its full call chain. Both generators are simply water/sand-heavy by design (thick maze corridors, real shorelines): instrumentation shows ~6,100 water and ~1,260 sand tiles for Lattice and ~4,100/~1,350 for Maze against a 16,384-tile map, and the sand-correction path costs one 31x31 weighted stamp per sand tile no matter how the adaptive rule picks between it and the water-splat alternative. first/second hold the box-blur passes' single-axis partial sums. The first pass (a plain 16-wide box over 0/1 water values) tops out at 16; combined with the second, backward pass into the full 1D triangular kernel it tops out at that kernel's own weight sum, 256; the vertical passes repeat the same shape against those values, topping out at 16*256=4096. Both fit uint16_t with no precision loss — the final `fertility` array still needs 32 bits, since its theoretical max (256*256=65536) is one past uint16_t's range. This is exact and safe, but measured negligible impact on Lattice/Maze specifically (their cost is the sand-correction term, which these arrays aren't part of). Worked through algebraically, that term reduces to sum_d weight(d) * water(target+d) * sand(target-d) - a bilinear cross-correlation between two different fields, not the single-field sum the box-blur trick relies on, so it does not have an equivalent separable speedup; only FFT-based convolution would change its asymptotic cost, and Fertility::Field is shared with FertilityCalculator (real gameplay fertility), so any such rewrite needs to preserve that caller's output exactly too. Left as an open follow-up rather than attempted here. Verified: full scons client build and scons -C test suite (198 cppunit cases, including FertilityFieldTest) pass with no narrowing warnings. MapGeneratorStudy succeeds across all 13 generators and produces bit-identical per-tile terrain/resource hashes before and after on matching seeds. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CkotX2daY8YTuFcRxFf5ba
…ipelago's hot loops Continuing the same profiling pass as the previous two commits: Shattered Coast (old-random) and Rugged Archipelago were the slowest and third-slowest of all 13 generators, and neither bottleneck was in their own terrain logic. GenerationContext::stream(name) looks up a named std::mt19937 in a std::map<std::string, std::mt19937>, computing a hash over the name and walking the tree on every call — cheap on its own, but both generators call it by string literal from deep inside per-tile loops that run tens of thousands of times per generation. Shattered Coast's simulateRandomMap (itself invoked repeatedly per smoothing iteration to balance water/sand/ grass ratios) draws from "simulation" up to five times per tile across every w*h-tile map it simulates; its terrain() draws from "terrain" the same way across its own patchwork and smoothing passes. Rugged Archipelago's island-growing and beach passes draw from "terrain" identically. In every one of these loops the stream name is a compile-time constant that never changes for the life of the function, so the repeated lookup was pure overhead paid on every single draw. Both functions now look their stream up once into a std::mt19937& at the top and draw from that reference throughout, instead of calling context.stream(name) again for every draw. This draws from the exact same underlying generator in the exact same order, so it changes no random number either function produces: MapGeneratorStudy's per-tile terrain/ resource hash is bit-identical on matching seeds before and after. Verified: full scons client build and scons -C test suite (198 cppunit cases) pass. MapGeneratorStudy succeeds across all 13 generators at 5 seeds each. Measured 50% faster on Shattered Coast and 31% faster on Rugged Archipelago end to end (interleaved before/after binaries, 60-100 generations per generator), with ~0% change on two unaffected generators run the same way, ruling out this machine's run-to-run noise as the source of the improvement. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CkotX2daY8YTuFcRxFf5ba
…n region splitting
Profiling identified Contested Commons, Concrete Islands and Isles as now the
slowest generators, dominated by the older Voronoi-style point-dispersion
code in shared/Regions.cpp and shared/Distances.cpp. Investigated with cache
behavior specifically in mind.
Exact fixes (same output, only how it's computed - verified by bit-identical
per-tile terrain/resource hashes on matching seeds, every generator):
- computeDistances' BFS used std::queue<int> (std::deque-backed, growing by
separately heap-allocated blocks) even though every cell is enqueued at
most once (the `side == 0` guard prevents a second push). A flat,
preallocated w*h-sized array makes an exact FIFO out of sequential writes
instead of block-to-block pointer chasing.
- splitUpArea's per-region frontier is inserted at a genuinely random
position on purpose - that randomness is what gives the flood its organic
shape, not an accident to remove - but std::vector<int> supports the
identical random-position insert/erase via a contiguous memmove, without
std::list's per-node heap allocation and pointer chasing on every single
insert and on the std::advance that finds where to insert.
- adjustHeightmapFromPerlinNoise and computeAverageDistance are a pure
per-cell transform and a commutative sum respectively, so nesting y
outside x costs nothing and walks both the grid and HeightMap's own
identically row-major _map array with their grain instead of across it.
- getAllPoints, getAllOtherPoints, findBorderPoints, and splitUpPoints's own
two internal scans all walked a row-major grid as `for x { for y {...} }`
- a full-row stride on every step - for no reason but habit, except two of
these results (possible[n], startingPoints[n]) get indexed by a random
draw, so a plain loop-order swap would silently pick a different point for
the same seed. collectPointsColumnOrder gets both: a row-major counting
pass sizes each output column, then a second row-major pass drops each
point into its precomputed slot, landing every point in the exact x-major,
y-minor order the original nested loop produced without ever striding
across the grid to do it. splitUpPoints' single-pass "reset the candidate
list on every strict improvement" site search reduces to the same set
every time regardless of how it's computed (traced by hand, confirmed by
the same hashes), so it is now an explicit two-pass max-then-collect using
the same helper.
One further fix changes output for Contested Commons specifically:
splitUpPoints' PointSearch::WholeRegion mode (Contested Commons' own search,
not used by any other generator) scores every legal tile as a candidate
placement and keeps only a strict improvement, so whichever tied candidate
is reached earliest in scan order wins - the one shape here a loop-order
swap could not fix without changing anything. Measured directly across 7
seeds: 6 of 7 produce byte-identical maps regardless, and the one that
changed still converges to a placement of the same quality by the search's
own metric (already documented as bounded best-response, not a guaranteed
optimum). PointSearch::Local - used far more broadly, including as
swamp/river/islands/crater lakes' rare legacy fallback - keeps its exact
original scan order untouched: its window is 7x7, too small for traversal
order to matter, and touches far more generators than this fix was worth
risking. Contested Commons' revision bump follows in the next commit.
Verified: full scons client build and scons -C test suite (198 cppunit
cases) pass. MapGeneratorStudy succeeds across all 13 generators at 9 seeds
each. Measured (interleaved before/after binaries, repeated 3x to get past
this machine's run-to-run noise - an initial single-run reading overstated
the effect by more than 2x): Concrete Islands ~9% faster, Isles ~5-7%
faster, Contested Commons ~8-9% faster overall. Fjord Continent, which
touches none of this code, showed ~2% change over the same run, taken as
the noise floor rather than a real effect.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CkotX2daY8YTuFcRxFf5ba
The previous commit's PointSearch::WholeRegion traversal-order change can change which exactly-tied candidate site wins a placement, so a given seed can produce a different (equally valid, by the search's own metric) map than before. Following this repository's existing convention for generation-output changes, bump the revision so that is visible rather than a silent side effect. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CkotX2daY8YTuFcRxFf5ba
FEEDBACK 2026-09-13 (third play): the tendrils were meant as "small roads but way more frequent, extending from the outer perimeter of the circle, inwards like 10 or 12 squares, maybe with a bit of meandering ... dozens of them, all the way around the perimeter, roughly evenly spaced ... a default width of 2 cells of sand". Old town (revision 4) now lays one wandering sand road every 8 tiles round the fields' cap ring (some seventy on a 256 map), 10 to 13 tiles long and two tiles wide, running in across the margin into the outer streets and notching the outer blocks; the earlier plaza-to-ring routes are gone. Verified on macOS arm64: golden table regenerated at revision 4 (264 rows, 0 failures), every Old town lobby cell on seeds 1 and 2, defaults test, strict translation check, client build. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GpUW6Ny43aUA1EzThn6cYE
FEEDBACK 2026-09-13 (third play): "right idea on old town but let's do 1/2 the number of those inwards roads". Old town (revision 5) lays a tendril every 16 tiles round the fields' cap ring instead of every 8: some thirty-five on a 256 map. Verified on macOS arm64: golden table regenerated at revision 5 (264 rows, 0 failures), every Old town lobby cell on seed 1, defaults test, client build. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GpUW6Ny43aUA1EzThn6cYE
…Canals from play FEEDBACK 2026-09-13 (first play of each), quoted in the code: - Marches (25) and Patchwork (33) are removed: "just not working conceptually at all" and "the concept is failing". Their ids are retired, never reused; shared/Biomes stays in the toolkit. - Fingerprint (revision 2): "looks very similar to everglades ... a higher wavelength has a nicer feel ... feels very empty ... home size needs to be larger". Wavelength 30 (was 20), homes of 18 (was 12), ambient layer more than doubled. - Rain shadow (revision 2): "not enough water; ponds deeper and more connected; sand roadways through the passes; valleys feel empty; rivers to inland lakes; sand patches". Streams 5 deep and 20 long every 24; a sand road through every pass and 12 tiles into the valley; inland lakes (inland-lakes) with wandering rivers to the nearest stream; sand-patches over 6% of the valleys' grass. - Polder (revision 2): "randomize the angle ... bases way too small, growth crowds them out ... 10x4 sand plots at 2.5x the players". row-angle is Random / Vertical / Horizontal / Diagonal (Random draws any angle and rounds to whole turn counts); villages of 14 in a two-tile sand ring; two and a half plots per colony (stampFarmPlot) spread through the rows away from the villages. - Canals (revision 2): "remove the home pond; 1.5x bridges; 2x warp; every cell has its own little surprise". Dry home blocks, extra bridges 30, warp 80, and every other block dealt one of nine kinds: plain, lake, orchard, a 4x4 pad in a sand ring, two pads, quarry, woodlot, wheatfield, dune. Verified on macOS arm64: golden table regenerated (248 rows, 0 failures), --sweep 0 failing combinations, defaults test, strict translation check, client build. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GpUW6Ny43aUA1EzThn6cYE
A design's home sites come in a fixed order (farthest-point spreading starts from cell 0, a lattice from its first row), so team 0 always started on the same ground: top right of Old town, for one. Pipeline.h gains dealStarts, a shuffle of a design's sites on its own stream, and every designed generator calls it before anything is keyed by colony index: Fingerprint, Old growth, Polder, Rain shadow, Canals, Anthill, Old town (homes and annexes together) and Maze. Validators rebuilding the design from a fresh context get the same deal. Revisions bumped and the golden table regenerated; a landscape check covers the deal (a permutation, repeatable, not the identity for most seeds). Also clears two warnings: an unused Torus alias in Anthill's validator and Polder's diagonal row width constant, now used for the same value. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GpUW6Ny43aUA1EzThn6cYE
128 maps had no farms (two home ponds instead) because a farm squeezed between the mountains kept losing a strip to its own coast walls: at 128x256 a strip joined to its field through an isthmus three tiles wide was sealed off when the walls of the coasts either side met across it, so the farm-reach check failed and the lobby retried a quarter of seeds. growFarmFields now keeps only the ground whose eroded core is joined to the home's core (two cores that do not touch can still overlap once grown back, through a waist the walls then close); the opening radius is a parameter with the old default, so Carousel is byte-identical. The size floor and the pond fallback go; 12 seeds pass at every 128-based cell. Revision 2, golden rows regenerated. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GpUW6Ny43aUA1EzThn6cYE
Third play: "the farms on switchback still don't seem to expand fully into the available area ... just ensure that the farms always grow to fill the available space" and "get rid of the water channels in between bases, similar to how we had to modify carousel". The fields kept three tiles of water from each other and from the mountains; on a 256x128 map that gap, cut through fields grown into fingers, took two fifths of the open sea and the opening most of the rest. Now the fields grow with no gap, every tile of sea is filled to its nearest field (fillToNearest with the whole map as reach), and a single line of stone stands on every border between colonies (labelBorders), as on Carousel; the farms' rim drops to 3 since no coast lies beside them. Sea within a level-3 tower's range of the plateau becomes rock first, so the mountains' inner ends join round the plateau and no farm reaches it. Revision 3, 12 seeds pass at every cell from 128x128/2 to 256x256/8, golden rows regenerated. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GpUW6Ny43aUA1EzThn6cYE
Third play: "we need to find a more reliable mechanism to divide the territory between players for amphitheatre ... wildly unsmooth borders. lets come up with something fair but also simple and reliable." The territories were grown a tile at a time by the smallest first with noise in the cost for a border-roughness control, then smoothed by a majority filter; the turn-taking grows fingers along every border and smoothing left them ragged. Territories.h gains balancedTerritories: every tile outside the wall goes to the colony whose ramp mouth is nearest by squared distance less a weight, a power diagram whose borders are straight lines, and the weights are tuned round by round (a step sized from the shortfall over the border, undone and halved when it made things worse) until the areas are within 1%. Each colony keeps a disc round its mouth whatever the balance, so a shifted border never crosses the way in. Steps and distance-from-arcs were tried first and swing instead of settling (documented in the header). The roughness control goes, with its key in all 34 tables. The validator now targets the pit tile each ramp can reach rather than the very middle, which the orchard ringed with fruit on some seeds (128x128/4 seed 1 was a golden "failed"). Revision 2; 12 seeds pass at every cell from 128/2 to 256/8. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GpUW6Ny43aUA1EzThn6cYE
Second play: "too many of them are empty of resources. like with the 4x4 squares - those squares are to hold farming buildings. they only matter if there are resources elsewhere on that island outside the sand square. can we also add some types that are more like forts? with some stone that guides enemies into a kill zone or something?" and "a few of them should be intended for building buildings on of course. but too many of them are boring right now." The pad kinds are now homesteads and hamlets with a wheat and a wood clump beside every pad; plain blocks fall from 24 in 100 to 6; and five kinds built of stone or water join, each dealt a facing: a fort (a 13-tile square of wall round a pad with one three-tile gate), a bastion (four corner walls, an opening in every side), a funnel (two walls in a V onto a three-tile gap, the pad behind it), a chicane (two staggered walls with a corridor between) and a moat (a ring of water round an islet with a pad and one sand causeway). Walls are drawn in the block's frame only where the warp left pure grass clear of water, and a block whose walls would cut its land or a bridge off gets none (blockWalls). Every pad and built kind carries the ambient fields too (15/10 in 100 of fertile tiles), a tile clear of the walls. Validator checks every wall's stone and every moat. Revision 4; 12 seeds pass at 128/2 to 512/4. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GpUW6Ny43aUA1EzThn6cYE
A self-review of the generators built and revised this week, with the golden table unchanged (248 rows, 0 failures) as the proof that no map moved. Boilerplate every designed generator copied goes into the toolkit: designFailure<design> (the registry's request check), and in Pipeline.h settleRoundColonies with homeGrassMask (round homes settled on their own grass) and homePondMissing (the validators' pond check); nearestSiteDistance (Orbits.h) replaces four copies of the same loop and tilePoint (Tessellation.h) two. Anthill keeps one list of chamber kinds (ChamberKind) instead of computing it in design and again in generate. Stale comments corrected: Switchbacks' pre-fill farm paragraph and "water between mountains" wording, Old town's wall (removed after the first play), Rain shadow's stream sizes, Old growth's pool-ring distance, Canals' "market blocks" that never existed, Polder's and Anthill's superseded defaults, Amphitheatre's removed roughness control; debug includes dropped. The four control keys left by the deleted Marches and Patchwork generators go from all 32 tables that held them, and the docs name the new helpers. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GpUW6Ny43aUA1EzThn6cYE
The nine landscapes and three walled arenas registered 47 strings that were left as English fallbacks in all 32 catalogs, and CI's translation gate fails on the first fallback it sees. Every one now has a translation, aligned with each catalog's existing terms for ridges, passes, homes and resources. Where a catalog's own word is spelled as the English is (Polder in the Germanic and most Slavic catalogs, Coral in es/pt/ro, Spiral in dk/id/sv/tr, Plazas in es, Canals in ca, Grain in fr, Diagonal in de/id) the word is listed as shared vocabulary in test/translation_shared_values.json rather than reworded. Seven keys no generator refers to any more (Marches, March width, Patchwork, Orchards, Straight, Gates and the old home-size failure) are removed from texts.keys.txt and every catalog. texts.br.txt is Brazilian Portuguese; an earlier pass wrote about fifty of this branch's entries (Coral, Spider web and the walled arenas) in Breton. They are rewritten in Portuguese, following texts.pt.txt with Brazilian forms (colônia, trilha, fazenda, menor). Verified: data/check_translations.py --strict, test/test_translations.py, test/test_font_coverage.py and test/test_text_area_layout.py all pass. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GpUW6Ny43aUA1EzThn6cYE
… style Four items from the review of b86c59b: - Tessellation.cpp named a vector `near`, a legacy windef.h macro that expands to nothing on mingw and broke the Windows build. Renamed `nearby`. - makeHomogenMap, controlSand and smoothResources had been folded into MapTerrain.cpp, which test/SConstruct links against a stripped server-mode libgag with no globalContainer, so the unit-test build no longer linked. They are back in their own translation unit, src/map/generator/MapHomogen.cpp, listed with the generator sources; nothing outside the generators and their tests calls them. - Stone Highlands' nearbyValleys returned before its sort when a fourth valley was in the window, so a valley pair could key the anyPair map in either order and the fallback pass-carving saw split candidate lists. The helper now keeps a sorted three-slot set and always returns it sorted. The same shape removes the std::sort over a runtime-bounded raw array that GCC 15 flagged with -Warray-bounds. The generator's revision moves to 3; the golden rows for the sampled seeds are unchanged, so the fix only bites where the fallback ran. - Glob2.cpp had two includes above its SPDX header and three space-indented lines in a tab-indented file. Verified: scons release=1 builds with no warnings; MapGeneratorDefaultsTest passes; MapGeneratorGoldenTest passes after --update (only the Stone Highlands revision column changed); test/MapExploredAreaSaveLoadTest builds and passes. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GpUW6Ny43aUA1EzThn6cYE
Brings in the CORN -> WHEAT identifier rename (#269) and the status-bar clamp (#194). The rename is applied to every file this branch adds or changes: 158 identifiers and comments across the generators, the shared toolkit, the generator tests and tools/MenuColonyHarness.cpp, so the branch has no CORN left outside the cortex-ml Python tools master also left alone. The five legacy generator files master renamed inside were already deleted here; FertilityCalculator.cpp, MapHomogen.cpp and MapGeneratorStudy.cpp keep this branch's versions with the rename applied. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GpUW6Ny43aUA1EzThn6cYE
…ences - MapGeneratorGoldenTest gains --require-rows: the same check, but a platform with no rows in the table fails instead of reporting and passing. The plain check keeps passing on a rowless platform so a new machine can run it before its rows exist. CI will pass the flag once the linux-x86_64 rows are committed; until then the workflow runs --print before the check, so the rows a platform would record are in the log even when the check fails. - CustomGamePreferences bounded the legacy riverDiameter slot at 100, and Old growth's lake size, which shares that slot, reaches 160: a saved lobby with a big lake failed to load and fell back to defaults, and CustomGameSetupHarness's whole-range round trip caught it. The envelope is now 0-160. Verified: CustomGameSetupHarness passes in default, preferences-write and preferences-read modes; MapGeneratorGoldenTest passes with and without --require-rows on macos-arm64; --sweep reports 0 failing combinations; test/TestsRunner and every test/ harness pass; MenuColonyHarness check and navigation pass. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GpUW6Ny43aUA1EzThn6cYE
The 248 linux-x86_64 rows come from the --print output of CI run 34839155040 on c6fb9d5; the ubuntu 22.04 (g++ 11) and 24.04 (g++ 13) jobs printed identical rows, so one platform tag covers both. 156 of the rows match macos-arm64 hash for hash; the other 92 are the generators that go through floating point (Fjord continent, Watershed at 512, Ring world and others), which is the per-platform caveat the table was designed around. With the rows in, the workflow runs the check as --require-rows, so a Linux job can no longer pass this step with nothing to compare, and the test README and framework reference say where a new platform's rows come from. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GpUW6Ny43aUA1EzThn6cYE
windef.h defines both as empty macros, and the Windows job stopped at Territories.h's `const int far` once Tessellation.cpp compiled. Every remaining use as an identifier is renamed: farthest in Morphology.cpp and Territories.h, clearance for the distance-to-keep-clear vectors in Old growth, Old town and Polder, and nearby/distant in the two test headers. Pure renames; the golden table is unchanged and the defaults test passes. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GpUW6Ny43aUA1EzThn6cYE
The Windows job failed SavegameSafetyHarness's check that a save leaves the sync RNG untouched, with the same harness and save code master passes there; the only change to the RNG on this branch is that `randomGenerator` became `thread_local`. A thread_local with a constructor that other translation units name directly is initialised through a weak per-unit wrapper, which mingw's emulated TLS is the one platform to get wrong. The engine is now a function-local thread_local behind `syncRandEngine()`, initialised in one place in Utilities.cpp, and every former use of the object (save and load, the menu colony's swap, the generation service's scope, the harnesses) calls the accessor. Same semantics everywhere else: golden maps, the defaults test, the savegame and trapped-unit harnesses and the setup harness all pass unchanged on macOS. The savegame harness now prints both RNG states when they differ instead of a bare assert, so the next platform that disagrees says how. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GpUW6Ny43aUA1EzThn6cYE
Review response, at
|
Play verdicts on the branch, each explained in the generator's header: - Tidal flats: six oases and eight sandbars per wedge by default (were three), drawn smaller and rounder with tighter gaps, so crowded wedges seat more of them; the smallest oasis keeps its pond in a ring of wheat. - Carousel: the sea inside the ring stays a lagoon with algae, the inward farm and the home kit are gone, and every lane or court on the lagoon keeps a 5-tile margin that is all stone (laneBand): on the 3-tile margin a diagonal spoke lost every grass tile down its middle to the beach and its seal, which cut colonies off from the plaza. - City states: an archipelago of round islets in the strait and the channels, each with the farms' 10x4 building plot and a small prize, two either side of every causeway on a four-colony map and more on a longer arc; drawn on the map about frame-designed middles so a bowed channel does not shear them, and a slot that would break its 3-tile moat in any wedge is left out of every wedge. The Barrens home layout is gone. Commons 45%, strait 11%. - Standard farms (Farmland): the sand bridges run clean across the whole farm, crop rows and water alike. - Amphitheatre: starting towers stand against the arena's outer wall within 18 steps of the colony's own ramp, covering the arena, not along the borders. - Starting tower level defaults to 1 on Carousel, Amphitheatre, Switchbacks and Canals. - Switchbacks, Carousel and Polder homes have no wheat and wood kit; Polder's wood share is 8% (was 15%). - Rain shadow: streams 7 deep (were 5), pass roads 18 tiles into each valley (were 12), ending in a lane of sand that joins the streams' beaches. - Canals: a `block-shape` choice of squares or hexagons, as on Maze. - Maze and Stone highlands amounts are percentages like every other landscape; the resource-amount audit is recorded in the framework doc. - The catalog order was shuffled once, so the list carries no bias towards the landscapes written first; the lobby and editor open on its first entry. Revisions bumped for every generator whose maps changed (Old town through the shared farm bridges); golden rows regenerated for macos-arm64 and linux-x86_64 (therig, gcc), --sweep 0 failing combinations. The saved-preferences bound for extra islands covers Tidal flats' new range. Docs updated in docs/map-generators/MAP_GENERATOR_FRAMEWORK.md. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GpUW6Ny43aUA1EzThn6cYE
…down - The custom game screen opens on a random map at four colonies when nothing is saved; the premade library preselects FourSquares1 the first time it is opened. A saved lobby still restores every setting, generator options included (verified by the preferences harness modes). - Reset to defaults moves to the top of the map column, right under the landscape chooser, with Random parameters beside it: every one of the landscape's controls drawn at random (GenerationRequest::randomizeControls), a set the generator refuses redrawn on the spot and one the world refuses redrawn when the preview's candidates come back empty, up to six times, so the first set that generates a valid map is the one shown. - The landscape picker gains Randomize parameters beside Regenerate all, with the same redraw rule per tile (LandscapePreviewer::reroll), and Use hands the lobby the parameters the shown map was rolled with as well as its seed. - Under a generated map's preview: its fairness and score, and an (i) that opens StartQualityScreen, the per-colony breakdown of what was measured, how each factor scored, the weights and the totals. - Labels that do not fit a compact button take the small font. - 13 new UI strings in all 32 catalogs; MenuColonyHarness drives the preview before reading the map header; CustomGameSetupHarness covers the new buttons, the breakdown screen and the picker's randomize. Docs updated in docs/custom-game-setup/README.md. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GpUW6Ny43aUA1EzThn6cYE
|
Play-note batch pushed as Screens and example maps: https://claude.ai/code/artifact/a10eb8c8-8dc2-49d6-9f73-f6645ff03755 (the compact lobby with Reset to defaults and Random parameters under the landscape chooser and the fairness/score line with its (i); the start quality breakdown; the picker after Randomize parameters; City states 256/4 with its archipelago; Carousel 256/3 with its lagoon and stone-banded spokes; Tidal flats 256/8 with six oases and eight sandbars per wedge). To reproduce locally: Verification on this head, macOS arm64 release: Two things worth a maintainer's eye in play: Carousel's spokes now cross a lagoon between bands of stone, and the catalog shuffle makes Fingerprint the lobby's opening landscape. |
…set to defaults - City states: the islets move out of the strait into the sea beyond the design circle, where the torus wraps (FEEDBACK 2026-09-14: "in the area outside the circle ... in like the empty space where the torus wraps", and bigger, since the plot "is taking up too much of the space"). One islet on the wrap point, then `islands` rings of eight, sixteen and so on at equal angles with the map's own four-fold symmetry (a rectangle's two points across the wrap on each axis get the same rings), radius 11, each with its 10x4 plot and a small prize; an islet whose disc and three-tile moat are not all sea is left out with its mirror images. The commons and strait defaults return to 55 and 4 percent now that the strait holds nothing. Revision 9; golden rows regenerated for both platforms, --sweep 0 failing combinations. - Landscape picker: Reset to defaults beside Randomize parameters puts every landscape back on its registered controls at the sheet's size and colony count and rolls the sheet again; harness coverage and docs. - Lobby: switching to the premade library drops random-map candidates still rolling, which would otherwise come back and replace the premade choice now that the lobby opens on a random map. - Harness: the random-parameters check gives the preview its redraw rounds (a refused random set aborted the visual run on ubuntu 24.04), and the SDL flow starts from the premade library as it was written to. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GpUW6Ny43aUA1EzThn6cYE
|
Follow-up The ubuntu 24.04 failure on the previous run was the visual harness: a random parameter set the world refused is redrawn by the preview itself, and the check only gave it one round. It now gives it the redraw rounds. Locally on macOS arm64: golden 248 rows with 0 failures under |
The xvfb run wrote only to artifacts/custom-game-ci/ui.log, which the job does not upload, so an assertion there left nothing to read but the exit code. The generation chatter is filtered and the rest goes to both places; the shell's pipefail keeps a failure failing. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GpUW6Ny43aUA1EzThn6cYE
The size controls are shared since #240, so lowering their minimum put 32 x 32 in the lobby too, where most landscapes cannot seat colonies and the setup harness's size step lands on an invalid map. The shared controls keep 64 as the smallest size; the editor's new-map screen and request validation use editorSizeControl, which reaches 32, and switching landscapes keeps the size. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WePqccHKmAjY6cwoqEcvBE
Map generation was spread across central dispatch code, overloaded descriptor fields, global random state and duplicated helpers, which made generators hard to add, compare or trust. This PR makes map generation modular, adds twelve new generators, makes starts measurably fairer, gives every generator tunable resource amounts and on/off switches, and gives the custom game lobby better control over random maps.
What changes
Framework. Each generator is a registered module with named controls, a revision, and optional request and world validation. The lobby, map editor, compatibility adapter, study tools and tests all read the same definitions.
GenerationServicevalidates the request, generates, validates the finished world, scores it, and keeps the best of five seeded rolls. Randomness comes only from named per-generation streams. Seedocs/map-generators/.Catalog. 20 playable generators plus editor-only Uniform. New:
Lattice was added and then removed within this branch, and never shipped.
Resource amounts and switches. Every playable generator now exposes the resources it places as amount controls, and its own sub-behaviours as on/off switches: 56 amount controls and 22 switches across 14 generators.
Fairer starts and resources.
guaranteeStartingResourcesclears resource walls that seal a colony off from wheat or wood, and tops up whatever is still missing. Designed walls can be protected from it.Lobby.
LandscapePickerScreen) that shows every landscape as a freshly generated map at the draft's current size and colony count, with colony markers and a Regenerate all button. Previews roll on background threads (LandscapePreviewer, at most four). The map you pick is the map you play: the lobby rolls the seed behind the tile you chose instead of sampling five, until the next edit. The picker takes only names and requests, so the editor and multiplayer lobby can reuse it.Fixes found along the way.
computeDistancesoverran its queue on repeated sources, which made Isles depend on leftover heap memory. Isles moves to revision 2, and its 128×128 seed 5 changes.Game's heap on construction; two games built at once corrupted each other's heaps. That update is now under a mutex.Translations. Every new label and message is in all 33 text tables — English plus 32 translations. Words a catalog's own language spells as English does (Polder, Coral, Spiral, Plazas, Canals, Grain, Diagonal in the languages concerned) are listed as reviewed shared vocabulary. The Brazilian Portuguese catalog's entries for this branch were rewritten after an earlier pass had used Breton.
Cleanup and safety net
A second pass over the generator tree, every step verified byte-identical against a golden table:
MapGeneratorGoldenTestkeeps a per-platform table of the map every generator produces for fixed seeds, sizes and colony counts, keyed by revision; a map that changes at an unchanged revision fails CI.--sweeprolls every playable landscape at every colony count the lobby offers and fails any cell where no seed generates. Its first run found Shattered coast unable to seat 12 colonies on most seeds; that placement now relaxes its spacing on a crowded map. macOS arm64 and Linux x86-64 rows are committed, and CI runs the check with--require-rows, so a platform CI builds on cannot pass it without rows;--printruns first so a new platform's rows are in its log..clang-formatin the house style (tabs, Allman, 100 columns) and the whole tree reformatted, with no map changed. The height map's raw arrays, debug dumps, noise macros and dead diagnostics are retired.shared/Gridholds the torus arithmetic, the eight-connected flood, the walkable mask, units by team and the colony-reachability check that seven generators and their validators each carried a copy of; 530 lines net removed.Fairness in real play
tools/map_fairness_tournament.pyplays Nicowar free-for-alls on one generated map across many engine seeds and asks whether the same starting positions keep winning, which is a different question from the structural scorescoreStartsreports. Per-start win counts get an exact multinomial or chi-square test, Wilson intervals, and Benjamini-Hochberg correction across generators;GLOB2_TEAM_RESULTSreports per-team elimination order, so a game that reaches the tick cap still yields a ranking instead of being discarded. Presets live intools/map-fairness/, the method is written up indocs/map-generators/FAIRNESS_TOURNAMENT.md, andtest/test_map_fairness_tournament.pycovers the statistics offline.What a partial run of 309 games across all 15 generators found:
scoreStartsis best read as a structural check rather than a fairness guarantee. It is also not rotation-invariant:build_sitescounts by top-left anchor.That run covered 2 of the 4 rotations and about 20 games per generator, so the pooled result is solid but per-generator conclusions are underpowered. The remaining games are not part of this PR.
Two full-rotation runs on this head (
tools/map_fairness_tournament.py, Nicowar in every slot, 128×128, 4 colonies, best of 5 lobby rolls per seed, tick cap 90,000; every rotation of team numbers over starts is played, so wins by start carry no team-index effect and wins by team index carry no map effect):baseline(Symmetric arena only, six maps, 96 games, 69 decided by elimination, 2 of 2 re-run games reproduced exactly): position bias 0.0 pp against a fair-map floor of 8.7, no map biased after Benjamini-Hochberg, wins by colony index 26 / 15 / 29 / 26 (p 0.17). Wins by team index 25 / 18 / 18 / 35 (p 0.053), team 3 at 36.5% [27.5%, 46.4%]: the engine's processing-order effect sits at the edge of detectability on a map with nothing else in it, and is the thing to keep measuring.smoke(Symmetric arena, Contested commons, Watershed, Crater lakes; three maps each, 96 games, 82 decided): the same numbers as the 2026-09-12 run inFAIRNESS_TOURNAMENT.md, game for game. Team index pooled over all four generators 28 / 21 / 23 / 24 (p 0.80). Symmetric arena shows no start bias (0.0 pp, floor 14.4). The three asymmetric generators do, per map rather than per colony index: Contested commons 35 pp with 3 of 3 maps biased, Watershed 33 pp with 2 of 3, Crater lakes 29 pp with 2 of 3, and the best start on those maps wins 2.5 to 3.5 times its fair share whichever team plays it. The start scorer only partly predicts which (rho 0.59 on Contested commons, 0.68 on Crater lakes, none on Watershed).The
standardpreset (every generator, 2,160 games, about 5.5 hours at 3 jobs) is not part of this PR; its generator list predates the landscapes and it belongs with the per-generator tuning that follows this merge. Reports and CSVs for both runs are underartifacts/map-fairness/locally and available on request.Compatibility
custom-game-settings.txtfiles still load, bridged through the legacy descriptor; the file gains an optional options section for the new controls. Round trip, older files and corrupt-file recovery are tested.Fertility::Fieldalso backs the editor's fertility tool and old-save migration; its effect on migrated saves has not been separately checked.syncRand()is nowthread_local, so the picker's background previews neither disturb nor race the menu's live colony. The simulation runs on one thread and its sequence is unchanged: headless--noxreplays from the previous and the new client binary are byte-identical over 1200 ticks (macOS arm64).Verification
Run on the head before City states (
82e01557f), macOS arm64, release build, and repeated for the contract tests and translations on the final head (7b221ff64):Build: full
sconsbuild.CI: linux (ubuntu:22.04), linux (ubuntu:24.04) and windows (mingw-w64) all pass.
nearandfarare macros in Windows'windef.h, and two generators had used them as identifiers, which kept the mingw job red; both are renamed, verified as a pure rename.MapGeneratorDefaultsTest: all four groups pass. That covers registry and control contracts, seed repeatability including after an intervening map, RNG isolation, legacy sentinels, algae placement, and lobby/editor control memory.CustomGameSetupHarness: passes headless, and windowed at 640×480 and 1000×700 with all five groups green at each size, including clicking Randomize, Reset to defaults and the new checkbox rows through the production controls.TestsRunner: 198/198.Offline tournament statistics:
test/test_map_fairness_tournament.pypasses.City states: 999/1000 single rolls over ten configurations (2 to 12 colonies, 128 to 512 maps) pass generation and its own world validation; the one failure is a 12-colony 512 roll whose landing walks spread past the bound, which the lobby's best-of-five absorbs. Rendered rolls, the sweep and fairness measurements are on a page linked from the review thread.
Tidal flats: 1000/1000 single rolls over ten configurations (2 to 12 colonies, 128 to 512 maps) pass generation and its own world validation.
Translations:
data/check_translations.py --strictreports 0 structural errors;test/test_translations.py,test/test_font_coverage.pyandtest/test_text_area_layout.pypass.Generation sweeps with
MapGeneratorStudy:Defaults unchanged: 45 map dumps (15 generators × 3 seeds) are byte-identical across both merges into this branch and across the Windows rename, so no generator revision moves beyond the Isles change noted above.
Shared-code changes were checked against before/after map dumps:
Cleanup pass (
57e424cdetoe2071ba27): golden check 126 rows with 0 failures after every step, sweep 0 failing cells,MapGeneratorDefaultsTestandCustomGameSetupHarness(headless, 640x480, 1000x700, SDL-driven flow, preferences modes) green on the final head; the full-rotationsmoketournament reproduced 2 of 2 re-run games exactly.Landscape picker and Contested commons fix (
adf81b19b,517ea16d9):CustomGameSetupHarnesspasses headless and windowed at 640×480 and 1000×700 (three runs each), with new coverage of the picker's previews on background threads, selection by click and keys, regeneration, and the lobby rolling the very seed the picker showed; a ThreadSanitizer build of the visual harness reports only a pre-existingVoiceRecorderteardown race;MapGeneratorDefaultsTestgains a regression rolling Contested commons at 256/8, 512/4 and 512/12 that fails on the previous code and passes now.Screenshots and example maps for every generator are in this comment. Seed studies were generated locally under ignored
artifacts/and are available on request.Spider web and its primitives (
745098856): new sharedDrawing(strokes, Bezier threads, turned shape fills),KitFrame,plantFields,scatterClumpsandsecureStartingCrops, with Tidal flats, City states, Everglades and Ring world moved onto them byte-identically (133 existing golden rows unchanged); golden 140 rows with 0 failures,--sweep0 failing combinations, a stress run of 1,662 / 1,742 rolls over 64–512 maps, 1–12 colonies and every control extreme (the misses are the up-front refusal of 6+ colonies on 64×64), median start fairness 0.965;MapGeneratorDefaultsTestwith new toolkit checks andcheck_translations.py --strictpass.Coral, rectangular maps and Spider web tuning (
e95f98865): new sharedgrowBranches,pathClearance,tracePath,Stretch,sprinkleSandandalgaeGrowthChance; golden 168 rows (a 512×256 row per generator added) with 0 failures and every other generator's square maps byte-identical;--sweep0 failing combinations; stress runs of 1,642 / 1,862 Coral rolls (median start fairness 0.966, every miss an up-front refusal) and 1,842 / 1,922 Spider web rolls (median 0.960) over 64–512 maps, 1–12 colonies and every control extreme; rectangle stress median fairness 0.96 for both;MapGeneratorDefaultsTest,check_translations.py --strictand clang-format pass.Review response (
ab95800c2, 2026-09-14): the mingwnearclash, thetest/link surface, the Stone Highlands pass sorting and GCC-Warray-boundsnoise,Glob2.cppstyle, 47 untranslated keys and the Old growth preferences bound, with master (Clamp status-bar fills instead of aborting on overflow #194, Rename the CORN resource identifier to WHEAT #269) merged in and the CORN → WHEAT rename applied branch-wide. Locally on macOS arm64: full build,MapGeneratorDefaultsTest, golden 248 rows with 0 failures (with and without--require-rows),--sweep0 failing combinations over 32 generators,CustomGameSetupHarnessin all three modes,MenuColonyHarness,test/TestsRunnerand everytest/harness, and all four translation checks. CI: run 34844458453 green on all three jobs, including the savegame harness on Windows, thetest/suite on Linux and the golden check against committed rows.Not run: cross-platform per-tick checksum comparison; maintainer playtesting of each generator; native-speaker review of the new translations, which are machine-assisted.
Play-note batch (2026-09-14)
Seventeen tweaks from playtesting the branch, each explained in the generator's header comment and in the docs:
block-shapechoice of squares or hexagons.Verification on macOS arm64 (release): full build;
MapGeneratorDefaultsTest; golden 248 rows with 0 failures under--require-rows, with linux-x86_64 rows regenerated on a Linux x86-64 box (gcc);--sweep0 failing combinations over 30 playable landscapes at 128 to 512 and 2 to 12 colonies;CustomGameSetupHarnessheadless with captures,preferences-writeandpreferences-read;MenuColonyHarness check; all four translation checks. Screenshots of the compact lobby with the new buttons and the fairness line, the breakdown screen, the randomized picker and example maps are on a results page linked from the review thread, and every one reproduces withbuild/src/CustomGameSetupHarness artifacts/custom-game/compact(captures) andtools/render_map.py one <landscape> --size 256 --set teams=N.Feel changes to playtest
Known limits
scoreStartscounts build sites by top-left anchor, so its fairness score reads just under 1.tools/colony_start_metrics.py). What is real is per map: on seven of nine asymmetric maps one start won seven or eight of eight games whichever team played it, and the start scorer only partly predicts which. The fairness reference records the numbers; teaching the scorer what a dominant start has is the open work.Features and gameplay changes need a human maintainer's review and playtest: @Giszmo, @kylelutze.
🤖 Generated with Claude Code
https://claude.ai/code/session_014TBmePvnWuhjRHwGdT5UPq
https://claude.ai/code/session_01GpUW6Ny43aUA1EzThn6cYE